// app/api/partners/rfq-last/[id]/response/route.ts import { NextRequest, NextResponse } from "next/server" import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" import db from "@/db/db" import { rfqLastVendorResponses, rfqLastVendorQuotationItems, rfqLastVendorAttachments, rfqLastVendorResponseHistory, rfqLastPriceAdjustmentForms } from "@/db/schema" import { eq, and } from "drizzle-orm" import { writeFile, mkdir } from "fs/promises" import { createWriteStream } from "fs" import { pipeline } from "stream/promises" import path from "path" import { v4 as uuidv4 } from "uuid" // 1GB 파일 지원을 위한 설정 export const config = { api: { bodyParser: { sizeLimit: '1gb', }, responseLimit: false, }, } // 스트리밍으로 파일 저장 async function saveFileStream(file: File, filepath: string) { const stream = file.stream() const writeStream = createWriteStream(filepath) await pipeline(stream, writeStream) } export async function POST( request: NextRequest, { params }: { params: { id: string } } ) { try { const session = await getServerSession(authOptions) if (!session?.user || session.user.domain !== "partners") { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } const rfqId = parseInt(params.id) const formData = await request.formData() const data = JSON.parse(formData.get('data') as string) const files = formData.getAll('attachments') as File[] // 업로드 디렉토리 생성 (벤더 응답용) const isDev = process.env.NODE_ENV === 'development' const uploadDir = isDev ? path.join(process.cwd(), 'public', 'uploads', 'rfq', rfqId.toString()) : path.join(process.env.NAS_PATH || '/nas', 'uploads', 'rfq', rfqId.toString()) await mkdir(uploadDir, { recursive: true }) // 트랜잭션 시작 const result = await db.transaction(async (tx) => { // 1. 벤더 응답 생성 const [vendorResponse] = await tx.insert(rfqLastVendorResponses).values({ rfqsLastId: data.rfqsLastId, rfqLastDetailsId: data.rfqLastDetailsId, vendorId: data.vendorId, responseVersion: 1, isLatest: true, participationStatus: "참여", participationRepliedAt: new Date(), participationRepliedBy: session.user.id, status: data.status || "작성중", submittedAt: data.submittedAt ? new Date(data.submittedAt) : null, submittedBy: data.submittedBy, totalAmount: data.totalAmount, currency: data.vendorCurrency || "USD", // 벤더 제안 조건 vendorCurrency: data.vendorCurrency, vendorPaymentTermsCode: data.vendorPaymentTermsCode, vendorIncotermsCode: data.vendorIncotermsCode, vendorIncotermsDetail: data.vendorIncotermsDetail, vendorDeliveryDate: data.vendorDeliveryDate ? new Date(data.vendorDeliveryDate) : null, vendorContractDuration: data.vendorContractDuration, vendorTaxCode: data.vendorTaxCode, vendorPlaceOfShipping: data.vendorPlaceOfShipping, vendorPlaceOfDestination: data.vendorPlaceOfDestination, // 특수 조건 응답 vendorFirstYn: data.vendorFirstYn, vendorFirstDescription: data.vendorFirstDescription, vendorFirstAcceptance: data.vendorFirstAcceptance, vendorSparepartYn: data.vendorSparepartYn, vendorSparepartDescription: data.vendorSparepartDescription, vendorSparepartAcceptance: data.vendorSparepartAcceptance, vendorMaterialPriceRelatedYn: data.vendorMaterialPriceRelatedYn, vendorMaterialPriceRelatedReason: data.vendorMaterialPriceRelatedReason, // 변경 사유 currencyReason: data.currencyReason, paymentTermsReason: data.paymentTermsReason, deliveryDateReason: data.deliveryDateReason, incotermsReason: data.incotermsReason, taxReason: data.taxReason, shippingReason: data.shippingReason, // 비고 generalRemark: data.generalRemark, technicalProposal: data.technicalProposal, createdBy: session.user.id, updatedBy: session.user.id, }).returning() // 2. 견적 아이템 저장 if (data.quotationItems && data.quotationItems.length > 0) { const quotationItemsData = data.quotationItems.map((item: any) => ({ vendorResponseId: vendorResponse.id, rfqPrItemId: item.rfqPrItemId, prNo: item.prNo, materialCode: item.materialCode, materialDescription: item.materialDescription, quantity: item.quantity || 0, uom: item.uom, unitPrice: item.unitPrice || 0, totalPrice: item.totalPrice || 0, currency: data.vendorCurrency || "USD", vendorDeliveryDate: item.vendorDeliveryDate ? new Date(item.vendorDeliveryDate) : null, leadTime: item.leadTime, manufacturer: item.manufacturer, manufacturerCountry: item.manufacturerCountry, modelNo: item.modelNo, technicalCompliance: item.technicalCompliance ?? true, alternativeProposal: item.alternativeProposal, discountRate: item.discountRate, itemRemark: item.itemRemark, deviationReason: item.deviationReason, })) await tx.insert(rfqLastVendorQuotationItems).values(quotationItemsData) } // 3. 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우) if (data.vendorMaterialPriceRelatedYn && data.priceAdjustmentForm && vendorResponse.id) { const priceAdjustmentData: { rfqLastVendorResponsesId: number; itemName?: string | null; adjustmentReflectionPoint?: string | null; majorApplicableRawMaterial?: string | null; adjustmentFormula?: string | null; rawMaterialPriceIndex?: string | null; referenceDate?: string | null; comparisonDate?: string | null; adjustmentRatio?: number | null; notes?: string | null; adjustmentConditions?: string | null; majorNonApplicableRawMaterial?: string | null; adjustmentPeriod?: string | null; contractorWriter?: string | null; adjustmentDate?: string | null; nonApplicableReason?: string | null; } = { rfqLastVendorResponsesId: vendorResponse.id, itemName: data.priceAdjustmentForm.itemName || null, adjustmentReflectionPoint: data.priceAdjustmentForm.adjustmentReflectionPoint || null, majorApplicableRawMaterial: data.priceAdjustmentForm.majorApplicableRawMaterial || null, adjustmentFormula: data.priceAdjustmentForm.adjustmentFormula || null, rawMaterialPriceIndex: data.priceAdjustmentForm.rawMaterialPriceIndex || null, referenceDate: data.priceAdjustmentForm.referenceDate || null, comparisonDate: data.priceAdjustmentForm.comparisonDate || null, adjustmentRatio: data.priceAdjustmentForm.adjustmentRatio || null, notes: data.priceAdjustmentForm.notes || null, adjustmentConditions: data.priceAdjustmentForm.adjustmentConditions || null, majorNonApplicableRawMaterial: data.priceAdjustmentForm.majorNonApplicableRawMaterial || null, adjustmentPeriod: data.priceAdjustmentForm.adjustmentPeriod || null, contractorWriter: data.priceAdjustmentForm.contractorWriter || null, adjustmentDate: data.priceAdjustmentForm.adjustmentDate || null, nonApplicableReason: data.priceAdjustmentForm.nonApplicableReason || null, } // 기존 연동제 정보가 있는지 확인 const existingPriceAdjustment = await tx .select() .from(rfqLastPriceAdjustmentForms) .where(eq(rfqLastPriceAdjustmentForms.rfqLastVendorResponsesId, vendorResponse.id)) .limit(1) if (existingPriceAdjustment.length > 0) { // 업데이트 await tx .update(rfqLastPriceAdjustmentForms) .set({ ...priceAdjustmentData, updatedAt: new Date(), }) .where(eq(rfqLastPriceAdjustmentForms.rfqLastVendorResponsesId, vendorResponse.id)) } else { // 새로 생성 await tx.insert(rfqLastPriceAdjustmentForms).values(priceAdjustmentData) } } // 4. 이력 기록 await tx.insert(rfqLastVendorResponseHistory).values({ vendorResponseId: vendorResponse.id, action: "생성", previousStatus: null, newStatus: data.status || "작성중", changeDetails: data, performedBy: session.user.id, }) return { id: vendorResponse.id, isNew: true } }) // 파일 저장 (트랜잭션 밖에서 처리) const fileRecords = [] if (files.length > 0) { console.log(`저장할 파일 수: ${files.length}`) console.log('파일 메타데이터:', data.fileMetadata) for (let i = 0; i < files.length; i++) { const file = files[i] // 파일 메타데이터에서 attachmentType 정보 가져옴 const metadata = data.fileMetadata && data.fileMetadata[i] const attachmentType = metadata?.attachmentType || "기타" const description = metadata?.description || "" console.log(`파일 ${i + 1} 처리: ${file.name}, 파일 객체 타입: ${attachmentType}`) try { const filename = `${uuidv4()}_${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}` const filepath = path.join(uploadDir, filename) // 대용량 파일은 스트리밍으로 저장 if (file.size > 50 * 1024 * 1024) { // 50MB 이상 await saveFileStream(file, filepath) } else { const buffer = Buffer.from(await file.arrayBuffer()) await writeFile(filepath, buffer) } fileRecords.push({ vendorResponseId: result.id, attachmentType: attachmentType, // 파일 객체에서 직접 가져옴 fileName: filename, originalFileName: file.name, filePath: `/uploads/rfq/${rfqId}/${filename}`, fileSize: file.size, fileType: file.type || path.extname(file.name).slice(1), description: description, uploadedBy: session.user.id, }) console.log(`파일 저장 완료: ${filename}, 타입: ${attachmentType}`) } catch (fileError) { console.error(`파일 저장 실패 ${file.name}:`, fileError) } } // DB에 파일 정보 저장 if (fileRecords.length > 0) { console.log('DB에 저장할 파일 레코드:', fileRecords) await db.insert(rfqLastVendorAttachments).values(fileRecords) } } return NextResponse.json({ success: true, data: result, message: data.status === "제출완료" ? "견적서가 성공적으로 제출되었습니다." : "견적서가 저장되었습니다.", filesUploaded: fileRecords.length }) } catch (error) { console.error("Error creating vendor response:", error) return NextResponse.json( { error: "Failed to create vendor response" }, { status: 500 } ) } } export async function PUT( request: NextRequest, { params }: { params: { id: string } } ) { try { const session = await getServerSession(authOptions) if (!session?.user || session.user.domain !== "partners") { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } const rfqId = parseInt(params.id) const formData = await request.formData() const data = JSON.parse(formData.get('data') as string) const files = formData.getAll('attachments') as File[] // 업로드 디렉토리 생성 (벤더 응답용) const isDev = process.env.NODE_ENV === 'development' const uploadDir = isDev ? path.join(process.cwd(), 'public', 'uploads', 'rfq-last-vendor-responses') : path.join(process.env.NAS_PATH || '/nas', 'uploads', 'rfq-last-vendor-responses') await mkdir(uploadDir, { recursive: true }) // 트랜잭션 시작 const result = await db.transaction(async (tx) => { // 1. 기존 응답 찾기 const existingResponse = await tx.query.rfqLastVendorResponses.findFirst({ where: and( eq(rfqLastVendorResponses.rfqsLastId, rfqId), eq(rfqLastVendorResponses.vendorId, data.vendorId), eq(rfqLastVendorResponses.isLatest, true) ) }) if (!existingResponse) { throw new Error("Response not found") } const previousStatus = existingResponse.status // 2. 새 버전 생성 (제출 시) 또는 기존 버전 업데이트 const responseId = existingResponse.id // if (data.status === "제출완료" && previousStatus !== "제출완료") { // // 기존 버전을 비활성화 // await tx.update(rfqLastVendorResponses) // .set({ isLatest: false }) // .where(eq(rfqLastVendorResponses.id, existingResponse.id)) // // 기존 첨부파일을 새 응답으로 이동 // if (existingResponse.id) { // await tx.update(rfqLastVendorAttachments) // .set({ vendorResponseId: existingResponse.id }) // 기존 응답에 연결 // .where(eq(rfqLastVendorAttachments.vendorResponseId, existingResponse.id)) // } // // 새 버전 생성 // const [newResponse] = await tx.insert(rfqLastVendorResponses).values({ // ...data, // vendorDeliveryDate: data.vendorDeliveryDate ? new Date(data.vendorDeliveryDate) : null, // submittedAt: data.submittedAt ? new Date(data.submittedAt) : null, // responseVersion: existingResponse.responseVersion + 1, // status:"제출완료", // participationStatus: "참여", // participationRepliedAt: existingResponse.participationRepliedAt ? new Date() : null, // participationRepliedBy: existingResponse.participationRepliedBy ? session.user.id : null, // isLatest: true, // createdBy: existingResponse.createdBy, // updatedBy: session.user.id, // }).returning() // // 기존 첨부파일을 새 응답으로 이동 // if (newResponse.id) { // await tx.update(rfqLastVendorAttachments) // .set({ vendorResponseId: newResponse.id }) // .where(eq(rfqLastVendorAttachments.vendorResponseId, existingResponse.id)) // } // responseId = newResponse.id // } else { // // 기존 버전 업데이트 if(data.status === "제출완료") { await tx.update(rfqLastVendorResponses) .set({ ...data, vendorDeliveryDate: data.vendorDeliveryDate ? new Date(data.vendorDeliveryDate) : null, submittedAt: data.submittedAt ? new Date(data.submittedAt) : null, status: data.status, updatedBy: session.user.id, updatedAt: new Date(), }) .where(eq(rfqLastVendorResponses.id, existingResponse.id)) } else { await tx.update(rfqLastVendorResponses) .set({ ...data, vendorDeliveryDate: data.vendorDeliveryDate ? new Date(data.vendorDeliveryDate) : null, submittedAt: data.submittedAt ? new Date(data.submittedAt) : null, updatedBy: session.user.id, updatedAt: new Date(), }) .where(eq(rfqLastVendorResponses.id, existingResponse.id)) } // 3. 견적 아이템 업데이트 // 기존 아이템 삭제 await tx.delete(rfqLastVendorQuotationItems) .where(eq(rfqLastVendorQuotationItems.vendorResponseId, existingResponse.id)) console.log(data.quotationItems,"data.quotationItems") // 새 아이템 추가 if (data.quotationItems && data.quotationItems.length > 0) { const quotationItemsData = data.quotationItems.map((item: any) => ({ vendorResponseId: responseId, rfqPrItemId: item.rfqPrItemId, prNo: item.prNo, materialCode: item.materialCode, materialDescription: item.materialDescription, quantity: item.quantity || 0, uom: item.uom, unitPrice: item.unitPrice || 0, totalPrice: item.totalPrice || 0, currency: data.vendorCurrency || "USD", vendorDeliveryDate: item.vendorDeliveryDate ? new Date(item.vendorDeliveryDate) : null, leadTime: item.leadTime, manufacturer: item.manufacturer, manufacturerCountry: item.manufacturerCountry, modelNo: item.modelNo, technicalCompliance: item.technicalCompliance ?? true, alternativeProposal: item.alternativeProposal, discountRate: item.discountRate, itemRemark: item.itemRemark, deviationReason: item.deviationReason, })) await tx.insert(rfqLastVendorQuotationItems).values(quotationItemsData) } // 4. 연동제 정보 저장/업데이트 (연동제 적용이 true이고 연동제 정보가 있는 경우) if (data.vendorMaterialPriceRelatedYn && data.priceAdjustmentForm && responseId) { const priceAdjustmentData: { rfqLastVendorResponsesId: number; itemName?: string | null; adjustmentReflectionPoint?: string | null; majorApplicableRawMaterial?: string | null; adjustmentFormula?: string | null; rawMaterialPriceIndex?: string | null; referenceDate?: string | null; comparisonDate?: string | null; adjustmentRatio?: number | null; notes?: string | null; adjustmentConditions?: string | null; majorNonApplicableRawMaterial?: string | null; adjustmentPeriod?: string | null; contractorWriter?: string | null; adjustmentDate?: string | null; nonApplicableReason?: string | null; } = { rfqLastVendorResponsesId: responseId, itemName: data.priceAdjustmentForm.itemName || null, adjustmentReflectionPoint: data.priceAdjustmentForm.adjustmentReflectionPoint || null, majorApplicableRawMaterial: data.priceAdjustmentForm.majorApplicableRawMaterial || null, adjustmentFormula: data.priceAdjustmentForm.adjustmentFormula || null, rawMaterialPriceIndex: data.priceAdjustmentForm.rawMaterialPriceIndex || null, referenceDate: data.priceAdjustmentForm.referenceDate || null, comparisonDate: data.priceAdjustmentForm.comparisonDate || null, adjustmentRatio: data.priceAdjustmentForm.adjustmentRatio || null, notes: data.priceAdjustmentForm.notes || null, adjustmentConditions: data.priceAdjustmentForm.adjustmentConditions || null, majorNonApplicableRawMaterial: data.priceAdjustmentForm.majorNonApplicableRawMaterial || null, adjustmentPeriod: data.priceAdjustmentForm.adjustmentPeriod || null, contractorWriter: data.priceAdjustmentForm.contractorWriter || null, adjustmentDate: data.priceAdjustmentForm.adjustmentDate || null, nonApplicableReason: data.priceAdjustmentForm.nonApplicableReason || null, } // 기존 연동제 정보가 있는지 확인 const existingPriceAdjustment = await tx .select() .from(rfqLastPriceAdjustmentForms) .where(eq(rfqLastPriceAdjustmentForms.rfqLastVendorResponsesId, responseId)) .limit(1) if (existingPriceAdjustment.length > 0) { // 업데이트 await tx .update(rfqLastPriceAdjustmentForms) .set({ ...priceAdjustmentData, updatedAt: new Date(), }) .where(eq(rfqLastPriceAdjustmentForms.rfqLastVendorResponsesId, responseId)) } else { // 새로 생성 await tx.insert(rfqLastPriceAdjustmentForms).values(priceAdjustmentData) } } else if (data.vendorMaterialPriceRelatedYn === false && responseId) { // 연동제 미적용 시 기존 데이터 삭제 await tx .delete(rfqLastPriceAdjustmentForms) .where(eq(rfqLastPriceAdjustmentForms.rfqLastVendorResponsesId, responseId)) } // 5. 이력 기록 await tx.insert(rfqLastVendorResponseHistory).values({ vendorResponseId: responseId, action: data.status === "제출완료" ? "제출" : "수정", previousStatus: previousStatus, newStatus: data.status, changeDetails: data, performedBy: session.user.id, }) return { id: responseId } }) // 파일 저장 (트랜잭션 밖에서) const fileRecords = [] if (files.length > 0) { console.log(`업데이트 저장할 파일 수: ${files.length}`) console.log('파일 메타데이터:', data.fileMetadata) for (let i = 0; i < files.length; i++) { const file = files[i] // 파일 메타데이터에서 attachmentType 정보 가져옴 const metadata = data.fileMetadata && data.fileMetadata[i] const attachmentType = metadata?.attachmentType || "기타" const description = metadata?.description || "" console.log(`업데이트 파일 ${i + 1} 처리: ${file.name}, 파일 객체 타입: ${attachmentType}`) try { const filename = `${uuidv4()}_${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}` const filepath = path.join(uploadDir, filename) // 대용량 파일은 스트리밍으로 저장 if (file.size > 50 * 1024 * 1024) { // 50MB 이상 await saveFileStream(file, filepath) } else { const buffer = Buffer.from(await file.arrayBuffer()) await writeFile(filepath, buffer) } fileRecords.push({ vendorResponseId: result.id, attachmentType: attachmentType, // 파일 객체에서 직접 가져옴 fileName: filename, originalFileName: file.name, filePath: `/uploads/rfq/${rfqId}/${filename}`, fileSize: file.size, fileType: file.type || path.extname(file.name).slice(1), description: description, uploadedBy: session.user.id, }) console.log(`업데이트 파일 저장 완료: ${filename}, 타입: ${attachmentType}`) } catch (fileError) { console.error(`업데이트 파일 저장 실패 ${file.name}:`, fileError) } } if (fileRecords.length > 0) { console.log('업데이트 DB에 저장할 파일 레코드:', fileRecords) await db.insert(rfqLastVendorAttachments).values(fileRecords) } } return NextResponse.json({ success: true, data: result, message: data.status === "제출완료" ? "견적서가 성공적으로 제출되었습니다." : "견적서가 수정되었습니다.", filesUploaded: fileRecords.length }) } catch (error) { console.error("Error updating vendor response:", error) return NextResponse.json( { error: "Failed to update vendor response" }, { status: 500 } ) } }